Skip to content

feat(mcp): OAuth 2.1 + PKCE for outbound MCP servers#4441

Open
waleedlatif1 wants to merge 33 commits intostagingfrom
waleedlatif1/mcp-oauth
Open

feat(mcp): OAuth 2.1 + PKCE for outbound MCP servers#4441
waleedlatif1 wants to merge 33 commits intostagingfrom
waleedlatif1/mcp-oauth

Conversation

@waleedlatif1
Copy link
Copy Markdown
Collaborator

Summary

  • Adds spec-compliant OAuth 2.1 + PKCE support for outbound MCP servers via the SDK's OAuthClientProvider
  • Auto-detects OAuth requirement on server create via unauthenticated probe (WWW-Authenticate / oauth-protected-resource)
  • Persists per-user-per-server tokens (encrypted) in new mcp_server_oauth table; SDK refreshes automatically before expiry
  • Popup-based consent flow (/api/mcp/oauth/start/api/mcp/oauth/callback) with state CSRF protection
  • Pre-registered OAuth client support (Client ID + Secret in Advanced settings) for servers without RFC 7591 DCR
  • Surfaces reauth_required from tool execution when refresh token is invalid so the UI can prompt to reconnect

Type of Change

  • New feature

Testing

Tested manually against OAuth-protected MCP servers (Linear). Existing header-auth servers regression-checked.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel
Copy link
Copy Markdown

vercel Bot commented May 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped May 6, 2026 3:09am

Request Review

@cursor
Copy link
Copy Markdown

cursor Bot commented May 5, 2026

PR Summary

High Risk
Adds a new OAuth 2.1 token storage and authorization flow (including encrypted secrets/tokens and per-user scoping), plus changes core MCP connection/tool execution paths; mistakes here can break connectivity or leak credentials across users.

Overview
Adds OAuth 2.1 + PKCE authorization for outbound MCP servers, including new /api/mcp/oauth/start and /api/mcp/oauth/callback routes that drive a popup-based consent flow and refresh tools after authorization.

Extends MCP server CRUD to support authType detection (probe on create), optional pre-registered oauthClientId/oauthClientSecret (encrypted at rest), and automatic invalidation of stored OAuth state when URL/credentials change; server list/update responses now expose hasOauthClientSecret instead of returning the secret.

Introduces a new encrypted per-(server,user) persistence layer (mcp_server_oauth table + storage helpers) and wires OAuth into the MCP client/service/connection manager with per-user connection scoping and explicit reauth_required handling surfaced to the UI/tool execution.

Reviewed by Cursor Bugbot for commit 4f3b32a. Configure here.

Comment thread apps/sim/lib/mcp/oauth/provider.ts
Comment thread apps/sim/hooks/queries/mcp.ts
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR adds full OAuth 2.1 + PKCE support for outbound MCP servers, including Dynamic Client Registration, encrypted token persistence in a new mcp_server_oauth table, a popup-based consent flow, and pre-registered credential support. It also scopes connection-manager cache keys per-user for OAuth connections to prevent cross-user token leakage.

  • Adds /api/mcp/oauth/start and /api/mcp/oauth/callback routes with CSRF state validation, PKCE, and session-user enforcement; encrypted storage for tokens, client info, and code verifiers.
  • Introduces detectMcpAuthType probe that fires unauthenticated on server creation to auto-classify servers as none / headers / oauth, with URL-unchanged guard to prevent transient probe failures from downgrading existing OAuth servers.
  • Surfaces reauth_required (HTTP 401) from tool execution when refresh tokens are rejected, and correctly marks expired-token servers as disconnected so the UI shows the re-auth button instead of a generic error.

Confidence Score: 5/5

Safe to merge; all previously identified blocking issues have been resolved and the OAuth flow is correctly guarded end-to-end.

The core OAuth flow — PKCE generation, state CSRF protection, encrypted storage, per-user connection keying, and reauth surfacing — is implemented correctly. Previous rounds fixed token cleanup on credential change, per-server button scoping, HTTPS enforcement on the authorization URL, code verifier encryption, and stale-connected-status after token expiry. The remaining findings are UX edge cases and probe classification nits that do not affect the security or correctness of the happy path.

No files require special attention; the new oauth/ layer and migration are self-contained and consistent with the existing encryption helpers.

Important Files Changed

Filename Overview
apps/sim/lib/mcp/oauth/provider.ts New SDK-compatible OAuthClientProvider; cleanly delegates storage to the DB layer and handles both DCR and pre-registered client paths.
apps/sim/lib/mcp/oauth/storage.ts Encrypts tokens, client info, and code verifier; stores state as a SHA-256 hash; handles insert race conditions with try/catch+re-read.
apps/sim/lib/mcp/oauth/probe.ts Fires a 5-second unauthenticated POST probe; falls back to 'headers' on timeout/error to avoid misclassifying unreachable servers.
apps/sim/app/api/mcp/oauth/callback/route.ts Validates session, burns state before token exchange, re-verifies user identity against the stored row, and triggers post-auth tool discovery.
apps/sim/app/api/mcp/oauth/start/route.ts Initiates SDK auth flow under withMcpAuth write-guard; returns authorizationUrl on redirect or already_authorized for valid existing tokens.
apps/sim/lib/mcp/connection-manager.ts Connection cache keys now scoped per-user for OAuth servers (serverId:userId), preventing cross-user token leakage.
apps/sim/lib/mcp/service.ts createClient accepts userId for OAuth; discoverAllTools and getServerSummaries correctly handle UnauthorizedError as 'disconnected'.
apps/sim/app/api/mcp/servers/route.ts POST upsert preserves existing authType when URL is unchanged, clears OAuth tokens on credential change, and only writes oauth fields when explicitly provided.
apps/sim/app/api/mcp/servers/[id]/route.ts PATCH runs inside a transaction to atomically update server and delete stale OAuth tokens; oauthClientSecret stripped from response.
apps/sim/hooks/queries/mcp.ts useStartMcpOauth validates authorization URL scheme before opening popup; query key hierarchy refactored for partial invalidation.
apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx OAuth button state scoped per-server via Set; postMessage listener validates origin; popup polling uses setInterval with unmount cleanup.
packages/db/schema.ts New mcp_server_oauth table with cascade deletes, unique index on (mcpServerId, userId), and state column index for callback lookup.

Sequence Diagram

sequenceDiagram
    participant User
    participant UI as Settings UI
    participant Start as /api/mcp/oauth/start
    participant AS as Authorization Server
    participant Callback as /api/mcp/oauth/callback
    participant DB as mcp_server_oauth
    participant Service as MCP Service

    User->>UI: Click "Connect with OAuth"
    UI->>Start: GET ?serverId=&workspaceId=
    Start->>DB: getOrCreateOauthRow()
    Start->>AS: mcpAuth() → DCR if needed
    AS-->>Start: McpOauthRedirectRequired(authorizationUrl)
    Start-->>UI: { status: redirect, authorizationUrl }
    UI->>AS: window.open(authorizationUrl) [popup]
    AS-->>Callback: GET ?code=&state= [redirect]
    Callback->>DB: loadOauthRowByState(hash(state))
    Callback->>DB: clearState()
    Callback->>AS: mcpAuth(provider, authorizationCode)
    AS-->>Callback: access_token + refresh_token
    Callback->>DB: saveTokens() [encrypted]
    Callback->>DB: clearVerifier()
    Callback->>Service: discoverServerTools()
    Callback-->>UI: postMessage mcp-oauth ok=true
    UI->>UI: invalidateQueries + toast

    Note over UI,Service: Tool execution path
    User->>Service: executeTool()
    Service->>DB: load tokens
    Service->>AS: callTool() via SDK (auto-refresh on 401)
    alt refresh fails
        AS-->>Service: UnauthorizedError
        Service-->>UI: reauth_required
    end
Loading

Reviews (21): Last reviewed commit: "chore(mcp): final audit pass — strip res..." | Re-trigger Greptile

Comment thread apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx Outdated
Comment thread apps/sim/lib/mcp/oauth/storage.ts
Comment thread apps/sim/lib/mcp/oauth/storage.ts
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

Greptile summary findings addressed in f587e82:

  • Edit modal drops existing OAuth Client ID: editInitialData now includes oauthClientId; the API already returns it (only the secret is masked) so the field populates and Advanced auto-expands.
  • Shared OAuth mutation disables all buttons: per-server pending tracked in a local Set<string>; the spinner is scoped to the card whose flow is in progress.
  • Plaintext PKCE codeVerifier: now encrypted at rest via encryptSecret to match tokens/clientInformation.

The point about clearing a pre-registered Client ID by emptying the field is a follow-up — oauthClientId || undefined collapses an intentional clear into a no-op. Will tackle when adding TTL cleanup for abandoned OAuth sessions.

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/mcp/servers/[id]/route.ts
Comment thread apps/sim/app/api/mcp/servers/[id]/route.ts
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/mcp/servers/[id]/route.ts Outdated
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/mcp/servers/route.ts Outdated
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/mcp/tools/execute/route.ts
Comment thread apps/sim/lib/mcp/service.ts Outdated
Comment thread apps/sim/lib/mcp/service.ts Outdated
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

Comment thread apps/sim/app/api/mcp/oauth/callback/route.ts
waleedlatif1 and others added 17 commits May 5, 2026 18:09
…entProvider conformance

- POST upsert now clears mcp_server_oauth rows when URL or client credentials change
- Validate https: scheme on authorizationUrl before window.open to prevent javascript: URI execution
- SimMcpOauthProvider now declares 'implements OAuthClientProvider' so SDK upgrades surface as compile errors
- Edit form only sends oauthClientId when changed, mirroring oauthClientSecret behavior

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…k error

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move popup-opening into the mutation result so the caller can track its
lifecycle. The 'Connecting…' spinner now stays until the user dismisses
or completes the OAuth popup, preventing accidental double-clicks that
would re-navigate the in-flight popup and invalidate state. Auto-OAuth
after server creation now uses the same shared helper for consistent
visual feedback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… on unmount

- POST upsert: when reviving a soft-deleted server, drop any prior
  mcpServerOauth rows so stale tokens never silently carry over.
- mcp.tsx: track the popup-closed setInterval per server in a ref and
  clear it on component unmount to avoid leaked timers.
- client.ts: don't log OAuth-redirect/Unauthorized as connect errors;
  these are expected control flow during the auth bootstrap.
- Use toError() for error message extraction.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The reference is only consumed by inline arrow handlers and is not
observed by any memoized child or effect dep array, so useCallback adds
overhead with no benefit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- useForceRefreshMcpTools: onSuccess → onSettled so cache reconciles on error
- useMcpServerTest: replace `instanceof Error` ternaries with `toError().message`
- mcp.tsx: use `--text-error` token (not the unused `--error`) and drop redundant dark variant

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- mcp.tsx: drop 8 useCallback wrappers (no React.memo'd children, no effect/memo deps observe them)
- mcp.tsx: drop filteredServers useMemo (cheap O(n) filter, no memoized consumers)
- mcp.tsx: serverToDelete {id, name} → serverToDeleteId; derive name from servers cache
- mcp-server-form-modal.tsx: drop 8 useCallback wrappers (same rationale)
- mcp-server-form-modal.tsx: drop hasChanges useMemo — deps change every keystroke so memo never caches
- mcp-server-form-modal.tsx: hover: → hover-hover: for codebase pointer:fine consistency

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…oken clear

- client.ts: pass requestInit.headers for OAuth servers too. Previously OAuth
  authType set requestInit to undefined, dropping all custom headers including
  SIM_VIA_HEADER for cross-call loop prevention. The SDK's authProvider adds
  Authorization on top, so user/system headers must still flow through.
- servers/[id]/route.ts: wrap server UPDATE and stale OAuth-token DELETE in a
  single transaction. Previously the update committed before the token clear,
  so a token-clear failure would leave new credentials with stale tokens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…in edit modal

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…Id on tool reauth errors

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- use mcp-oauth-${serverId} window target so concurrent OAuth flows on
  different servers don't reuse and clobber the same popup
- drop redundant setQueryData before invalidate in useForceRefreshMcpTools
- replace hardcoded text-red-500 with text-[var(--text-error)] token
- normalize Plus icon to default h-[14px] w-[14px]
- drop useMemo on cheap toolsByServer/selectedServer derivations

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- hoist serverId from try-block const into outer scope so the catch's
  htmlClose carries it through to postMessage. Without it, parent's
  onMessage couldn't clear connectingOauthServers and the UI button
  stayed stuck on "Connecting…" until popup close.
- relax https-only authorization URL check to permit http://localhost,
  http://127.0.0.1, and http://[::1] per OAuth 2.1 loopback exemption,
  unblocking local OAuth-protected MCP server development.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the old 0202_unknown_newton_destine migration which collided
with staging's 0202/0203/0204. Bumps API validation route baseline to
735 to account for the two new MCP OAuth endpoints.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…er bug

- Prevent stored Authorization header from overwriting OAuth Bearer in McpClient
- Per-user connection cache keying in connection-manager (token-leak prevention)
- Tighten types in use-mcp-tools and tools/execute (drop `any`)
- Replace raw <button> with emcn Button in mcp settings + form modal
- Modal Cancel: variant='ghost' → 'default' to match design system
- Derive editInitialData and showDeleteDialog from existing state
- Replace refreshingServers Record + chained timer with mutation state
- Trigger MCP OAuth start on create when authType==='oauth' from tool-input
- Invalidate servers/storedTools alongside tools on force-refresh

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ments

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c45e164. Configure here.

Comment thread apps/sim/app/api/mcp/servers/[id]/route.ts Outdated
Comment thread apps/sim/app/api/mcp/oauth/callback/route.ts
…, callback escape

- Extract useMcpOauthPopup hook so the tool-input "add server" flow gets the
  same postMessage/popup-poll lifecycle as the settings page.
- PATCH /mcp/servers/:id now returns hasOauthClientSecret to mirror GET.
- Escape '<' / '>' inside the JSON-stringified serverId emitted in the
  callback's <script> tag for defense-in-depth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant